Completed
Push — master ( be48c3...a95bbf )
by MusikAnimal
02:34
created

application.js ➔ ... ➔ $(window).scroll.toc   B

Complexity

Conditions 6
Paths 6

Size

Total Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
c 1
b 1
f 0
dl 0
loc 26
rs 8.439
cc 6
nc 6
nop 1
1
(function () {
2
    var sortDirection, sortColumn, $tocClone, tocHeight, sectionOffset = {},
3
        toggleTableData, apiPath, lastProject;
4
5
    // Load translations with 'en.json' as a fallback
6
    var messagesToLoad = {};
7
8
    /** global: i18nLang */
9
    /** global: i18nPath */
10
    messagesToLoad[i18nLang] = i18nPath;
11
12
    /** global: i18nEnPath */
13
    if (i18nLang !== 'en') {
14
        messagesToLoad.en = i18nEnPath;
15
    }
16
17
    $.i18n({
18
        locale: i18nLang
19
    }).load(messagesToLoad);
20
21
    $(document).ready(function () {
22
        $('.xt-hide').on('click', function () {
23
            $(this).hide();
24
            $(this).siblings('.xt-show').show();
25
            $(this).parents('.panel-heading').siblings('.panel-body').hide();
26
        });
27
        $('.xt-show').on('click', function () {
28
            $(this).hide();
29
            $(this).siblings('.xt-hide').show();
30
            $(this).parents('.panel-heading').siblings('.panel-body').show();
31
        });
32
33
        setupNavCollapsing();
34
        setupColumnSorting();
35
        setupTOC();
36
        setupStickyHeader();
37
        setupProjectListener();
38
        setupAutocompletion();
39
        displayWaitingNoticeOnSubmission();
40
41
        // Re-init forms, workaround for issues with Safari and Firefox.
42
        // See displayWaitingNoticeOnSubmission() for more.
43
        window.onpageshow = function (e) {
44
            if (e.persisted) {
45
                displayWaitingNoticeOnSubmission(true);
46
            }
47
        };
48
    });
49
50
    /**
51
     * Script to make interactive toggle table and pie chart.
52
     * For visual example, see the "Semi-automated edits" section of the AutoEdits tool.
53
     *
54
     * Example usage (see autoEdits/result.html.twig and js/autoedits.js for more):
55
     *     <table class="table table-bordered table-hover table-striped toggle-table">
56
     *         <thead>...</thead>
57
     *         <tbody>
58
     *             {% for tool, values in semi_automated %}
59
     *             <tr>
60
     *                 <!-- use the 'linked' class here because the cell contains a link -->
61
     *                 <td class="sort-entry--tool linked" data-value="{{ tool }}">
62
     *                     <span class="toggle-table--toggle" data-index="{{ loop.index0 }}" data-key="{{ tool }}">
63
     *                         <span class="glyphicon glyphicon-remove"></span>
64
     *                         <span class="color-icon" style="background:{{ chartColor(loop.index0) }}"></span>
65
     *                     </span>
66
     *                     {{ wiki.pageLink(...) }}
67
     *                 </td>
68
     *                 <td class="sort-entry--count" data-value="{{ values.count }}">
69
     *                     {{ values.count }}
70
     *                 </td>
71
     *             </tr>
72
     *             {% endfor %}
73
     *             ...
74
     *         </tbody>
75
     *     </table>
76
     *     <div class="toggle-table--chart">
77
     *         <canvas id="tool_chart" width="400" height="400"></canvas>
78
     *     </div>
79
     *     <script>
80
     *         window.toolsChart = new Chart($('#tool_chart'), { ... });
81
     *         window.countsByTool = {{ semi_automated | json_encode() | raw }};
82
     *         ...
83
     *
84
     *         // See autoedits.js for more
85
     *         window.setupToggleTable(window.countsByTool, window.toolsChart, 'count', function (newData) {
86
     *             // update the totals in toggle table based on newData
87
     *         });
88
     *     </script>
89
     *
90
     * @param  {Object}   dataSource     Object of data that makes up the chart
91
     * @param  {Chart}    chartObj       Reference to the pie chart associated with the .toggle-table
92
     * @param  {String}   [valueKey]     The name of the key within entries of dataSource,
93
     *                                   where the value is what's shown in the chart.
94
     *                                   If omitted or null, `dataSource` is assumed to be of the structure:
95
     *                                   { 'a' => 123, 'b' => 456 }
96
     * @param  {Function} updateCallback Callback to update the .toggle-table totals. `toggleTableData`
97
     *                                   is passed in which contains the new data, you just need to
98
     *                                   format it (maybe need to use i18n, update multiple cells, etc.)
99
     */
100
    window.setupToggleTable = function (dataSource, chartObj, valueKey, updateCallback) {
101
        $('.toggle-table').on('click', '.toggle-table--toggle', function () {
102
            if (!toggleTableData) {
103
                // must be cloned
104
                toggleTableData = Object.assign({}, dataSource);
105
            }
106
107
            var index = $(this).data('index'),
108
                key = $(this).data('key');
109
110
            // must use .attr instead of .prop as sorting script will clone DOM elements
111
            if ($(this).attr('data-disabled') === 'true') {
112
                toggleTableData[key] = dataSource[key];
113
                var oldValue = parseInt(valueKey ? toggleTableData[key][valueKey] : toggleTableData[key], 10);
114
                chartObj.data.datasets[0].data[index] = oldValue;
115
                $(this).attr('data-disabled', 'false');
116
            } else {
117
                delete toggleTableData[key];
118
                chartObj.data.datasets[0].data[index] = null;
119
                $(this).attr('data-disabled', 'true');
120
            }
121
122
            // gray out row in table
123
            $(this).parents('tr').toggleClass('excluded');
124
125
            // change the hover icon from a 'x' to a '+'
126
            $(this).find('.glyphicon').toggleClass('glyphicon-remove').toggleClass('glyphicon-plus');
127
128
            // update stats
129
            updateCallback(toggleTableData);
130
131
            chartObj.update();
132
        });
133
    }
134
135
    /**
136
     * If there are more tool links in the nav than will fit in the viewport,
137
     *   move the last entry to the More menu, one at a time, until it all fits.
138
     * This does not listen for window resize events.
139
     */
140
    function setupNavCollapsing()
141
    {
142
        var windowWidth = $(window).width(),
143
            toolNavWidth = $('.tool-links').outerWidth(),
144
            navRightWidth = $('.nav-buttons').outerWidth();
145
146
        // Ignore if in mobile responsive view
147
        if (windowWidth < 768) {
148
            return;
149
        }
150
151
        // Do this first so we account for the space the More menu takes up
152
        if (toolNavWidth + navRightWidth > windowWidth) {
153
            $('.tool-links--more').removeClass('hidden');
154
        }
155
156
        // Don't loop more than there are links in the nav.
157
        // This more just a safeguard against an infinite loop should something go wrong.
158
        var numLinks = $('.tool-links--entry').length;
159
        while (numLinks > 0 && toolNavWidth + navRightWidth > windowWidth) {
160
            // Remove the last tool link that is not the current tool being used
161
            var $link = $('.tool-links--nav > .tool-links--entry:not(.active)').last().remove();
162
            $('.tool-links--more .dropdown-menu').append($link);
163
            toolNavWidth = $('.tool-links').outerWidth();
164
            numLinks--;
165
        }
166
    }
167
168
    /**
169
     * Sorting of columns
170
     *
171
     *  Example usage:
172
     *   {% for key in ['username', 'edits', 'minor', 'date'] %}
173
     *      <th>
174
     *         <span class="sort-link sort-link--{{ key }}" data-column="{{ key }}">
175
     *            {{ msg(key) | capitalize }}
176
     *            <span class="glyphicon glyphicon-sort"></span>
177
     *         </span>
178
     *      </th>
179
     *  {% endfor %}
180
     *   <th class="sort-link" data-column="username">Username</th>
181
     *   ...
182
     *   <td class="sort-entry--username" data-value="{{ username }}">{{ username }}</td>
183
     *   ...
184
     *
185
     * Data type is automatically determined, with support for integer,
186
     *   floats, and strings, including date strings (e.g. "2016-01-01 12:59")
187
     */
188
    function setupColumnSorting()
189
    {
190
        $('.sort-link').on('click', function () {
191
            sortDirection = sortColumn === $(this).data('column') ? -sortDirection : 1;
192
193
            $('.sort-link .glyphicon').removeClass('glyphicon-sort-by-alphabet-alt glyphicon-sort-by-alphabet').addClass('glyphicon-sort');
194
            var newSortClassName = sortDirection === 1 ? 'glyphicon-sort-by-alphabet-alt' : 'glyphicon-sort-by-alphabet';
195
            $(this).find('.glyphicon').addClass(newSortClassName).removeClass('glyphicon-sort');
196
197
            sortColumn = $(this).data('column');
198
            var $table = $(this).parents('table');
199
            var entries = $table.find('.sort-entry--' + sortColumn).parent();
200
201
            if (!entries.length) {
202
                return; }
203
204
            entries.sort(function (a, b) {
205
                var before = $(a).find('.sort-entry--' + sortColumn).data('value'),
206
                    after = $(b).find('.sort-entry--' + sortColumn).data('value');
207
208
                // test data type, assumed to be string if can't be parsed as float
209
                if (!isNaN(parseFloat(before, 10))) {
210
                    before = parseFloat(before, 10);
211
                    after = parseFloat(after, 10);
212
                }
213
214
                if (before < after) {
215
                    return sortDirection;
216
                } else if (before > after) {
217
                    return -sortDirection;
218
                } else {
219
                    return 0;
220
                }
221
            });
222
223
            $table.find('tbody').html($(entries));
224
        });
225
    }
226
227
    /**
228
     * Floating table of contents
229
     *
230
     * Example usage (see articleInfo/result.html.twig for more):
231
     *     <p class="text-center xt-heading-subtitle">
232
     *         ...
233
     *     </p>
234
     *     <div class="text-center xt-toc">
235
     *         {% set sections = ['generalstats', 'usertable', 'yearcounts', 'monthcounts'] %}
236
     *         {% for section in sections %}
237
     *             <span>
238
     *                 <a href="#{{ section }}" data-section="{{ section }}">{{ msg(section) }}</a>
239
     *             </span>
240
     *         {% endfor %}
241
     *     </div>
242
     *     ...
243
     *     {% set content %}
244
     *         ...content for general stats...
245
     *     {% endset %}
246
     *     {{ layout.content_block('generalstats', content) }}
247
     *     ...
248
     */
249
    function setupTOC()
250
    {
251
        var $toc = $('.xt-toc');
252
253
        if (!$toc || !$toc[0]) {
254
            return;
255
        }
256
257
        tocHeight = $toc.height();
258
259
        // listeners on the section links
260
        var setupTocListeners = function () {
261
            $('.xt-toc').find('a').off('click').on('click', function (e) {
262
                document.activeElement.blur();
263
                var $newSection = $('#' + $(e.target).data('section'));
264
                $(window).scrollTop($newSection.offset().top - tocHeight);
265
266
                $(this).parents('.xt-toc').find('a').removeClass('bold');
267
268
                createTocClone();
269
                $tocClone.addClass('bold');
270
            });
271
        };
272
273
        // clone the TOC and add position:fixed
274
        var createTocClone = function () {
275
            if ($tocClone) {
276
                return;
277
            }
278
            $tocClone = $toc.clone();
279
            $tocClone.addClass('fixed');
280
            $toc.after($tocClone);
281
            setupTocListeners();
282
        };
283
284
        // build object containing offsets of each section
285
        var buildSectionOffsets = function () {
286
            $.each($toc.find('a'), function (index, tocMember) {
287
                var id = $(tocMember).data('section');
288
                sectionOffset[id] = $('#' + id).offset().top;
289
            });
290
        }
291
292
        // rebuild section offsets when sections are shown/hidden
293
        $('.xt-show, .xt-hide').on('click', buildSectionOffsets);
294
295
        buildSectionOffsets();
296
        setupTocListeners();
297
298
        var tocOffsetTop = $toc.offset().top;
299
        $(window).on('scroll.toc', function (e) {
300
            var windowOffset = $(e.target).scrollTop();
301
            var inRange = windowOffset > tocOffsetTop;
302
303
            if (inRange) {
304
                if (!$tocClone) {
305
                    createTocClone();
306
                }
307
308
                // bolden the link for whichever section we're in
309
                var $activeMember;
310
                Object.keys(sectionOffset).forEach(function (section) {
311
                    if (windowOffset > sectionOffset[section] - tocHeight - 1) {
312
                        $activeMember = $tocClone.find('a[data-section="' + section + '"]');
0 ignored issues
show
Bug introduced by
The variable $tocClone seems to not be initialized for all possible execution paths.
Loading history...
313
                    }
314
                });
315
                $tocClone.find('a').removeClass('bold');
316
                if ($activeMember) {
317
                    $activeMember.addClass('bold');
318
                }
319
            } else if (!inRange && $tocClone) {
320
                // remove the clone once we're out of range
321
                $tocClone.remove();
322
                $tocClone = null;
323
            }
324
        });
325
    }
326
327
    /**
328
     * Make any tables with the class 'table-sticky-header' have sticky headers.
329
     * E.g. as you scroll the heading row will be fixed at the top for reference.
330
     */
331
    function setupStickyHeader()
332
    {
333
        var $header = $('.table-sticky-header');
334
335
        if (!$header || !$header[0]) {
336
            return;
337
        }
338
339
        var headerHeight = $header.height(),
0 ignored issues
show
Unused Code introduced by
The variable headerHeight seems to be never used. Consider removing it.
Loading history...
340
            $headerRow = $header.find('thead tr').eq(0),
341
            $headerClone;
342
343
        // Make a clone of the header to maintain placement of the original header,
344
        // making the original header the sticky one. This way event listeners on it
345
        // (such as column sorting) will still work.
346
        var cloneHeader = function () {
347
            if ($headerClone) {
348
                return;
349
            }
350
351
            $headerClone = $headerRow.clone();
352
            $headerRow.addClass('sticky-heading');
353
            $headerRow.before($headerClone);
354
355
            // Explicitly set widths of each column, which are lost with position:absolute.
356
            $headerRow.find('th').each(function (index) {
357
                $(this).css('width', $headerClone.find('th').eq(index).outerWidth());
358
            });
359
            $headerRow.css('width', $headerClone.outerWidth() + 1);
360
        };
361
362
        var headerOffsetTop = $header.offset().top;
363
        $(window).on('scroll.stickyHeader', function (e) {
364
            var windowOffset = $(e.target).scrollTop();
365
            var inRange = windowOffset > headerOffsetTop;
366
367
            if (inRange && !$headerClone) {
368
                cloneHeader();
369
            } else if (!inRange && $headerClone) {
370
                // Remove the clone once we're out of range,
371
                // and make the original un-sticky.
372
                $headerRow.removeClass('sticky-heading');
373
                $headerClone.remove();
374
                $headerClone = null;
375
            } else if ($headerClone) {
376
                // The header is position:absolute so it will follow with X scrolling,
377
                // but for Y we must go by the window scroll position.
378
                $headerRow.css(
379
                    'top',
380
                     $(window).scrollTop() - $header.offset().top
381
                );
382
            }
383
        });
384
    }
385
386
    /**
387
     * Add listener to the project input field to update any
388
     * namespace selectors and autocompletion fields.
389
     */
390
    function setupProjectListener()
391
    {
392
        // Stop here if there is no project field
393
        if (!$("#project_input")) {
394
            return;
395
        }
396
397
        // If applicable, setup namespace selector with real time updates when changing projects.
398
        // This will also set `apiPath` so that autocompletion will query the right wiki.
399
        if ($('#project_input').length && $('#namespace_select').length) {
400
            setupNamespaceSelector();
401
        // Otherwise, if there's a user or page input field, we still need to update `apiPath`
402
        // for the user input autocompletion when the project is changed.
403
        } else if ($('#user_input')[0] || $('#article_input')[0]) {
404
            // keep track of last valid project
405
            lastProject = $('#project_input').val();
406
407
            $('#project_input').on('change', function () {
408
                var newProject = this.value;
409
410
                // Show the spinner.
411
                $(this).addClass('show-loader');
412
413
                /** global: xtBaseUrl */
414
                $.get(xtBaseUrl + 'api/normalizeProject/' + newProject).done(function (data) {
415
                    // Keep track of project API path for use in page title autocompletion
416
                    apiPath = data.api;
417
                    lastProject = newProject;
418
                    setupAutocompletion();
419
                }).fail(
420
                    revertToValidProject.bind(this, newProject)
0 ignored issues
show
Unused Code introduced by
The call to bind does not seem necessary since the function revertToValidProject declared on line 490 does not use this.
Loading history...
421
                ).always(function () {
422
                    $('#project_input').removeClass('show-loader');
423
                });
424
            });
425
        }
426
    }
427
428
    /**
429
     * Use the wiki input field to populate the namespace selector.
430
     * This also updates `apiPath` and calls setupAutocompletion()
431
     */
432
    function setupNamespaceSelector()
433
    {
434
        // keep track of last valid project
435
        lastProject = $('#project_input').val();
436
437
        $('#project_input').on('change', function () {
438
            // Disable the namespace selector and show a spinner while the data loads.
439
            $('#namespace_select').prop('disabled', true);
440
            $(this).addClass('show-loader');
441
442
            var newProject = this.value;
443
444
            /** global: xtBaseUrl */
445
            $.get(xtBaseUrl + 'api/namespaces/' + newProject).done(function (data) {
446
                // Clone the 'all' option (even if there isn't one),
447
                // and replace the current option list with this.
448
                var $allOption = $('#namespace_select option[value="all"]').eq(0).clone();
449
                $("#namespace_select").html($allOption);
450
451
                // Keep track of project API path for use in page title autocompletion
452
                apiPath = data.api;
453
454
                // Add all of the new namespace options.
455
                for (var ns in data.namespaces) {
456
                    if (!data.namespaces.hasOwnProperty(ns)) {
457
                        continue; // Skip keys from the prototype.
458
                    }
459
460
                    var nsName = parseInt(ns, 10) === 0 ? $.i18n('mainspace') : data.namespaces[ns];
461
                    $('#namespace_select').append(
462
                        "<option value=" + ns + ">" + nsName + "</option>"
463
                    );
464
                }
465
                // Default to mainspace being selected.
466
                $("#namespace_select").val(0);
467
                lastProject = newProject;
468
469
                // Re-init autocompletion
470
                setupAutocompletion();
471
            }).fail(revertToValidProject.bind(this, newProject)).always(function () {
0 ignored issues
show
Unused Code introduced by
The call to bind does not seem necessary since the function revertToValidProject declared on line 490 does not use this.
Loading history...
472
                $('#namespace_select').prop('disabled', false);
473
                $('#project_input').removeClass('show-loader');
474
            });
475
        });
476
477
        // If they change the namespace, update autocompletion,
478
        // which will ensure only pages in the selected namespace
479
        // show up in the autocompletion
480
        $('#namespace_select').on('change', setupAutocompletion);
481
    }
482
483
    /**
484
     * Called by setupNamespaceSelector or setupProjectListener
485
     *   when the user changes to a project that doesn't exist.
486
     * This throws a warning message and reverts back to the
487
     *   last valid project.
488
     * @param {string} newProject - project they attempted to add
489
     */
490
    function revertToValidProject(newProject)
491
    {
492
        $('#project_input').val(lastProject);
493
        $('.site-notice').append(
494
            "<div class='alert alert-warning alert-dismissible' role='alert'>" +
495
                $.i18n('invalid-project', "<strong>" + newProject + "</strong>") +
496
                "<button class='close' data-dismiss='alert' aria-label='Close'>" +
497
                    "<span aria-hidden='true'>&times;</span>" +
498
                "</button>" +
499
            "</div>"
500
        );
501
    }
502
503
    /**
504
     * Setup autocompletion of pages if a page input field is present.
505
     */
506
    function setupAutocompletion()
507
    {
508
        var $articleInput = $('#article_input'),
509
            $userInput = $('#user_input'),
510
            $namespaceInput = $("#namespace_select");
511
512
        // Make sure typeahead-compatible fields are present
513
        if (!$articleInput[0] && !$userInput[0] && !$('#project_input')[0]) {
514
            return;
515
        }
516
517
        // Destroy any existing instances
518
        if ($articleInput.data('typeahead')) {
519
            $articleInput.data('typeahead').destroy();
520
        }
521
        if ($userInput.data('typeahead')) {
522
            $userInput.data('typeahead').destroy();
523
        }
524
525
        // set initial value for the API url, which is put as a data attribute in forms.html.twig
526
        if (!apiPath) {
527
            apiPath = $('#article_input').data('api') || $('#user_input').data('api');
528
        }
529
530
        // Defaults for typeahead options. preDispatch and preProcess will be
531
        // set accordingly for each typeahead instance
532
        var typeaheadOpts = {
533
            url: apiPath,
534
            timeout: 200,
535
            triggerLength: 1,
536
            method: 'get',
537
            loadingClass: 'show-loader',
538
            preDispatch: null,
539
            preProcess: null,
540
        };
541
542
        if ($articleInput[0]) {
543
            $articleInput.typeahead({
544
                ajax: Object.assign(typeaheadOpts, {
545
                    preDispatch: function (query) {
546
                        // If there is a namespace selector, make sure we search
547
                        // only within that namespace
548
                        if ($namespaceInput[0] && $namespaceInput.val() !== '0') {
549
                            var nsName = $namespaceInput.find('option:selected').text().trim();
550
                            query = nsName + ':' + query;
551
                        }
552
                        return {
553
                            action: 'query',
554
                            list: 'prefixsearch',
555
                            format: 'json',
556
                            pssearch: query
557
                        };
558
                    },
559
                    preProcess: function (data) {
560
                        var nsName = '';
561
                        // Strip out namespace name if applicable
562
                        if ($namespaceInput[0] && $namespaceInput.val() !== '0') {
563
                            nsName = $namespaceInput.find('option:selected').text().trim();
564
                        }
565
                        return data.query.prefixsearch.map(function (elem) {
566
                            return elem.title.replace(new RegExp('^' + nsName + ':'), '');
567
                        });
568
                    },
569
                })
570
            });
571
        }
572
573
        if ($userInput[0]) {
574
            $userInput.typeahead({
575
                ajax: Object.assign(typeaheadOpts, {
576
                    preDispatch: function (query) {
577
                        return {
578
                            action: 'query',
579
                            list: 'prefixsearch',
580
                            format: 'json',
581
                            pssearch: 'User:' + query
582
                        };
583
                    },
584
                    preProcess: function (data) {
585
                        var results = data.query.prefixsearch.map(function (elem) {
586
                            return elem.title.split('/')[0].substr(elem.title.indexOf(':') + 1);
587
                        });
588
589
                        return results.filter(function (value, index, array) {
590
                            return array.indexOf(value) === index;
591
                        });
592
                    },
593
                })
594
            });
595
        }
596
    }
597
598
    /**
599
     * For any form submission, this disables the submit button and replaces its text with
600
     * a loading message and a counting timer.
601
     * @param {boolean} [undo] Revert the form back to the initial state.
602
     *                         This is used on page load to solve an issue with Safari and Firefox
603
     *                         where after browsing back to the form, the "loading" state persists.
604
     */
605
    function displayWaitingNoticeOnSubmission(undo)
606
    {
607
        if (undo) {
608
            // Re-enable form
609
            $('.form-control').prop('readonly', false);
610
            $('.form-submit').prop('disabled', false);
611
            $('.form-submit').text($.i18n('submit')).prop('disabled', false);
612
        } else {
613
            $('#content form').on('submit', function () {
614
                // Remove focus from any active element
615
                document.activeElement.blur();
616
617
                // Disable the form so they can't hit Enter to re-submit
618
                $('.form-control').prop('readonly', true);
619
620
                // Change the submit button text.
621
                $('.form-submit').prop('disabled', true)
622
                    .html($.i18n('loading') + " <span id='submit_timer'></span>");
623
624
                // Add the counter.
625
                var startTime = Date.now();
626
                setInterval(function () {
627
                    var elapsedSeconds = Math.round((Date.now() - startTime) / 1000);
628
                    var minutes = Math.floor(elapsedSeconds / 60);
629
                    var seconds = ('00' + (elapsedSeconds - (minutes * 60))).slice(-2);
630
                    $('#submit_timer').text(minutes + ":" + seconds);
631
                }, 1000);
632
            });
633
        }
634
    }
635
636
})();
637